/**
 * Copyright 2007-2012 Arthur Blake
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package net.sf.log4jdbc;

import java.io.IOException;
import java.io.InputStream;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.Driver;
import java.sql.DriverManager;
import java.sql.DriverPropertyInfo;
import java.sql.SQLException;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.TreeSet;

/**
 * A JDBC driver which is a facade that delegates to one or more real underlying
 * JDBC drivers.  The driver will spy on any other JDBC driver that is loaded,
 * simply by prepending <code>jdbc:log4</code> to the normal jdbc driver URL
 * used by any other JDBC driver. The driver, by default, also loads several
 * well known drivers at class load time, so that this driver can be
 * "dropped in" to any Java program that uses these drivers without making any
 * code changes.
 * <p/>
 * The well known driver classes that are loaded are:
 * <p/>
 * <p/>
 * <code>
 * <ul>
 * <li>oracle.jdbc.driver.OracleDriver</li>
 * <li>com.sybase.jdbc2.jdbc.SybDriver</li>
 * <li>net.sourceforge.jtds.jdbc.Driver</li>
 * <li>com.microsoft.jdbc.sqlserver.SQLServerDriver</li>
 * <li>com.microsoft.sqlserver.jdbc.SQLServerDriver</li>
 * <li>weblogic.jdbc.sqlserver.SQLServerDriver</li>
 * <li>com.informix.jdbc.IfxDriver</li>
 * <li>org.apache.derby.jdbc.ClientDriver</li>
 * <li>org.apache.derby.jdbc.EmbeddedDriver</li>
 * <li>com.mysql.jdbc.Driver</li>
 * <li>org.postgresql.Driver</li>
 * <li>org.hsqldb.jdbcDriver</li>
 * <li>org.h2.Driver</li>
 * </ul>
 * </code>
 * <p/>
 * <p/>
 * Additional drivers can be set via a property: <b>log4jdbc.drivers</b>
 * This can be either a single driver class name or a list of comma separated
 * driver class names.
 * <p/>
 * The autoloading behavior can be disabled by setting a property:
 * <b>log4jdbc.auto.load.popular.drivers</b> to false.  If that is done, then
 * the only drivers that log4jdbc will attempt to load are the ones specified
 * in <b>log4jdbc.drivers</b>.
 * <p/>
 * If any of the above driver classes cannot be loaded, the driver continues on
 * without failing.
 * <p/>
 * Note that the <code>getMajorVersion</code>, <code>getMinorVersion</code> and
 * <code>jdbcCompliant</code> method calls attempt to delegate to the last
 * underlying driver requested through any other call that accepts a JDBC URL.
 * <p/>
 * This can cause unexpected behavior in certain circumstances.  For example,
 * if one of these 3 methods is called before any underlying driver has been
 * established, then they will return default values that might not be correct
 * in all situations.  Similarly, if this spy driver is used to spy on more than
 * one underlying driver concurrently, the values returned by these 3 method
 * calls may change depending on what the last underlying driver used was at the
 * time.  This will not usually be a problem, since the driver is retrieved by
 * it's URL from the DriverManager in the first place (thus establishing an
 * underlying real driver), and in most applications their is only one database.
 *
 * @author Arthur Blake
 */
public class DriverSpy implements Driver
{
  /**
   * The last actual, underlying driver that was requested via a URL.
   */
  private Driver lastUnderlyingDriverRequested;

  /**
   * Maps driver class names to RdbmsSpecifics objects for each kind of
   * database.
   */
  private static Map rdbmsSpecifics;

  static final SpyLogDelegator log = SpyLogFactory.getSpyLogDelegator();

  /**
   * Optional package prefix to use for finding application generating point of
   * SQL.
   */
  static String DebugStackPrefix;

  /**
   * Flag to indicate debug trace info should be from the calling application
   * point of view (true if DebugStackPrefix is set.)
   */
  static boolean TraceFromApplication;

  /**
   * Flag to indicate if a warning should be shown if SQL takes more than
   * SqlTimingWarnThresholdMsec milliseconds to run.  See below.
   */
  static boolean SqlTimingWarnThresholdEnabled;

  /**
   * An amount of time in milliseconds for which SQL that executed taking this
   * long or more to run shall cause a warning message to be generated on the
   * SQL timing logger.
   *
   * This threshold will <i>ONLY</i> be used if SqlTimingWarnThresholdEnabled
   * is true.
   */
  static long SqlTimingWarnThresholdMsec;

  /**
   * Flag to indicate if an error should be shown if SQL takes more than
   * SqlTimingErrorThresholdMsec milliseconds to run.  See below.
   */
  static boolean SqlTimingErrorThresholdEnabled;

  /**
   * An amount of time in milliseconds for which SQL that executed taking this
   * long or more to run shall cause an error message to be generated on the
   * SQL timing logger.
   *
   * This threshold will <i>ONLY</i> be used if SqlTimingErrorThresholdEnabled
   * is true.
   */
  static long SqlTimingErrorThresholdMsec;

  /**
   * When dumping boolean values, dump them as 'true' or 'false'.
   * If this option is not set, they will be dumped as 1 or 0 as many
   * databases do not have a boolean type, and this allows for more
   * portable sql dumping.
   */
  static boolean DumpBooleanAsTrueFalse;

  /**
   * When dumping SQL, if this is greater than 0, than the SQL will
   * be broken up into lines that are no longer than this value.
   */
  static int DumpSqlMaxLineLength;

  /**
   * If this is true, display a special warning in the log along with the SQL
   * when the application uses a Statement (as opposed to a PreparedStatement.)
   * Using Statements for frequently used SQL can sometimes result in
   * performance and/or security problems.
   */
  static boolean StatementUsageWarn;

  /**
   * Options to more finely control which types of SQL statements will
   * be dumped, when dumping SQL.
   * By default all 5 of the following will be true.  If any one is set to
   * false, then that particular type of SQL will not be dumped.
   */
  static boolean DumpSqlSelect;
  static boolean DumpSqlInsert;
  static boolean DumpSqlUpdate;
  static boolean DumpSqlDelete;
  static boolean DumpSqlCreate;

  // only true if one ore more of the above 4 flags are false.
  static boolean DumpSqlFilteringOn;

  /**
   * If true, add a semilcolon to the end of each SQL dump.
   */
  static boolean DumpSqlAddSemicolon;

  /**
   * If dumping in debug mode, dump the full stack trace.
   * This will result in a VERY voluminous output, but can be very useful
   * under some circumstances.
   */
  static boolean DumpFullDebugStackTrace;

  /**
   * Attempt to Automatically load a set of popular JDBC drivers?
   */
  static boolean AutoLoadPopularDrivers;

  /**
   * Trim SQL before logging it?
   */
  static boolean TrimSql;

  /**
   * Remove extra Lines in the SQL that consist of only white space?
   * Only when 2 or more lines in a row like this occur, will the extra lines (beyond 1)
   * be removed.
   */
  static boolean TrimExtraBlankLinesInSql;

  /**
   * Coldfusion typically calls PreparedStatement.getGeneratedKeys() after
   * every SQL update call, even if it's not warranted.  This typically produces
   * an exception that is ignored by Coldfusion.  If this flag is true, then
   * any exception generated by this method is also ignored by log4jdbc.
   */
  static boolean SuppressGetGeneratedKeysException;

  /**
   * Get a Long option from a property and
   * log a debug message about this.
   *
   * @param props Properties to get option from.
   * @param propName property key.
   *
   * @return the value of that property key, converted
   * to a Long.  Or null if not defined or is invalid.
   */
  private static Long getLongOption(Properties props, String propName)
  {
    String propValue = props.getProperty(propName);
    Long longPropValue = null;
    if (propValue == null)
    {
      log.debug("x " + propName + " is not defined");
    }
    else
    {
      try
      {
        longPropValue = new Long(Long.parseLong(propValue));
        log.debug("  " + propName + " = " + longPropValue);
      }
      catch (NumberFormatException n)
      {
        log.debug("x " + propName + " \"" + propValue  +
          "\" is not a valid number");
      }
    }
    return longPropValue;
  }

  /**
   * Get a Long option from a property and
   * log a debug message about this.
   *
   * @param props Properties to get option from.
   * @param propName property key.
   *
   * @return the value of that property key, converted
   * to a Long.  Or null if not defined or is invalid.
   */
  private static Long getLongOption(Properties props, String propName,
    long defaultValue)
  {
    String propValue = props.getProperty(propName);
    Long longPropValue;
    if (propValue == null)
    {
      log.debug("x " + propName + " is not defined (using default of " +
        defaultValue +")");
      longPropValue = new Long(defaultValue);
    }
    else
    {
      try
      {
        longPropValue = new Long(Long.parseLong(propValue));
        log.debug("  " + propName + " = " + longPropValue);
      }
      catch (NumberFormatException n)
      {
        log.debug("x " + propName + " \"" + propValue  +
          "\" is not a valid number (using default of " + defaultValue +")");
        longPropValue = new Long(defaultValue);
      }
    }
    return longPropValue;
  }

  /**
   * Get a String option from a property and
   * log a debug message about this.
   *
   * @param props Properties to get option from.
   * @param propName property key.
   * @return the value of that property key.
   */
  private static String getStringOption(Properties props, String propName)
  {
    String propValue = props.getProperty(propName);
    if (propValue == null || propValue.length()==0)
    {
      log.debug("x " + propName + " is not defined");
      propValue = null; // force to null, even if empty String
    }
    else
    {
      log.debug("  " + propName + " = " + propValue);
    }
    return propValue;
  }

  /**
   * Get a boolean option from a property and
   * log a debug message about this.
   *
   * @param props Properties to get option from.
   * @param propName property name to get.
   * @param defaultValue default value to use if undefined.
   *
   * @return boolean value found in property, or defaultValue if no property
   *         found.
   */
  private static boolean getBooleanOption(Properties props, String propName,
    boolean defaultValue)
  {
    String propValue = props.getProperty(propName);
    boolean val;
    if (propValue == null)
    {
      log.debug("x " + propName + " is not defined (using default value " +
        defaultValue + ")");
      return defaultValue;
    }
    else
    {
      propValue = propValue.trim().toLowerCase();
      if (propValue.length() == 0)
      {
        val = defaultValue;
      }
      else
      {
        val= "true".equals(propValue) ||
          "yes".equals(propValue) || "on".equals(propValue);
      }
    }
    log.debug("  " + propName + " = " + val);
    return val;
  }

  static
  {
    log.debug("... log4jdbc initializing ...");

    InputStream propStream =
      DriverSpy.class.getResourceAsStream("/log4jdbc.properties");

    Properties props = new Properties(System.getProperties());
    if (propStream != null)
    {
      try
      {
        props.load(propStream);
      }
      catch (IOException e)
      {
        log.debug("ERROR!  io exception loading " +
          "log4jdbc.properties from classpath: " + e.getMessage());
      }
      finally
      {
        try
        {
          propStream.close();
        }
        catch (IOException e)
        {
          log.debug("ERROR!  io exception closing property file stream: " +
            e.getMessage());
        }
      }
      log.debug("  log4jdbc.properties loaded from classpath");
    }
    else
    {
      log.debug("  log4jdbc.properties not found on classpath");
    }

    // look for additional driver specified in properties
    DebugStackPrefix = getStringOption(props, "log4jdbc.debug.stack.prefix");
    TraceFromApplication = DebugStackPrefix != null;

    Long thresh = getLongOption(props, "log4jdbc.sqltiming.warn.threshold");
    SqlTimingWarnThresholdEnabled = (thresh != null);
    if (SqlTimingWarnThresholdEnabled)
    {
      SqlTimingWarnThresholdMsec = thresh.longValue();
    }

    thresh = getLongOption(props, "log4jdbc.sqltiming.error.threshold");
    SqlTimingErrorThresholdEnabled = (thresh != null);
    if (SqlTimingErrorThresholdEnabled)
    {
      SqlTimingErrorThresholdMsec = thresh.longValue();
    }

    DumpBooleanAsTrueFalse =
      getBooleanOption(props, "log4jdbc.dump.booleanastruefalse",false);

    DumpSqlMaxLineLength = getLongOption(props,
      "log4jdbc.dump.sql.maxlinelength", 90L).intValue();

    DumpFullDebugStackTrace =
      getBooleanOption(props, "log4jdbc.dump.fulldebugstacktrace",false);

    StatementUsageWarn =
      getBooleanOption(props, "log4jdbc.statement.warn",false);

    DumpSqlSelect = getBooleanOption(props, "log4jdbc.dump.sql.select",true);
    DumpSqlInsert = getBooleanOption(props, "log4jdbc.dump.sql.insert",true);
    DumpSqlUpdate = getBooleanOption(props, "log4jdbc.dump.sql.update",true);
    DumpSqlDelete = getBooleanOption(props, "log4jdbc.dump.sql.delete",true);
    DumpSqlCreate = getBooleanOption(props, "log4jdbc.dump.sql.create",true);

    DumpSqlFilteringOn = !(DumpSqlSelect && DumpSqlInsert && DumpSqlUpdate &&
      DumpSqlDelete && DumpSqlCreate);

    DumpSqlAddSemicolon = getBooleanOption(props,
      "log4jdbc.dump.sql.addsemicolon", false);

    AutoLoadPopularDrivers = getBooleanOption(props,
      "log4jdbc.auto.load.popular.drivers", true);

    TrimSql = getBooleanOption(props, "log4jdbc.trim.sql", true);

    TrimExtraBlankLinesInSql = getBooleanOption(props, "log4jdbc.trim.sql.extrablanklines", true);

    SuppressGetGeneratedKeysException =
      getBooleanOption(props, "log4jdbc.suppress.generated.keys.exception",
      false);

    // The Set of drivers that the log4jdbc driver will preload at instantiation
    // time.  The driver can spy on any driver type, it's just a little bit
    // easier to configure log4jdbc if it's one of these types!

    Set subDrivers = new TreeSet();

    if (AutoLoadPopularDrivers)
    {
      subDrivers.add("oracle.jdbc.driver.OracleDriver");
      subDrivers.add("oracle.jdbc.OracleDriver");
      subDrivers.add("com.sybase.jdbc2.jdbc.SybDriver");
      subDrivers.add("net.sourceforge.jtds.jdbc.Driver");

      // MS driver for Sql Server 2000
      subDrivers.add("com.microsoft.jdbc.sqlserver.SQLServerDriver");

      // MS driver for Sql Server 2005
      subDrivers.add("com.microsoft.sqlserver.jdbc.SQLServerDriver");

      subDrivers.add("weblogic.jdbc.sqlserver.SQLServerDriver");
      subDrivers.add("com.informix.jdbc.IfxDriver");
      subDrivers.add("org.apache.derby.jdbc.ClientDriver");
      subDrivers.add("org.apache.derby.jdbc.EmbeddedDriver");
      subDrivers.add("com.mysql.jdbc.Driver");
      subDrivers.add("org.postgresql.Driver");
      subDrivers.add("org.hsqldb.jdbcDriver");
      subDrivers.add("org.h2.Driver");
    }

    // look for additional driver specified in properties
    String moreDrivers = getStringOption(props, "log4jdbc.drivers");

    if (moreDrivers != null)
    {
      String[] moreDriversArr = moreDrivers.split(",");

      for (int i = 0; i < moreDriversArr.length; i++)
      {
        subDrivers.add(moreDriversArr[i]);
        log.debug ("    will look for specific driver " + moreDriversArr[i]);
      }
    }

    try
    {
      DriverManager.registerDriver(new DriverSpy());
    }
    catch (SQLException s)
    {
      // this exception should never be thrown, JDBC just defines it
      // for completeness
      throw (RuntimeException) new RuntimeException
        ("could not register log4jdbc driver!").initCause(s);
    }

    // instantiate all the supported drivers and remove
    // those not found
    String driverClass;
    for (Iterator i = subDrivers.iterator(); i.hasNext();)
    {
      driverClass = (String) i.next();
      try
      {
        Class.forName(driverClass);
        log.debug("  FOUND DRIVER " + driverClass);
      }
      catch (Throwable c)
      {
        i.remove();
      }
    }

    if (subDrivers.size() == 0)
    {
      log.debug("WARNING!  " +
        "log4jdbc couldn't find any underlying jdbc drivers.");
    }

    SqlServerRdbmsSpecifics sqlServer = new SqlServerRdbmsSpecifics();
    OracleRdbmsSpecifics oracle = new OracleRdbmsSpecifics();
    MySqlRdbmsSpecifics mySql = new MySqlRdbmsSpecifics();

    /** create lookup Map for specific rdbms formatters */
    rdbmsSpecifics = new HashMap();
    rdbmsSpecifics.put("oracle.jdbc.driver.OracleDriver", oracle);
    rdbmsSpecifics.put("oracle.jdbc.OracleDriver", oracle);
    rdbmsSpecifics.put("net.sourceforge.jtds.jdbc.Driver", sqlServer);
    rdbmsSpecifics.put("com.microsoft.jdbc.sqlserver.SQLServerDriver",
      sqlServer);
    rdbmsSpecifics.put("weblogic.jdbc.sqlserver.SQLServerDriver", sqlServer);
    rdbmsSpecifics.put("com.mysql.jdbc.Driver", mySql);
    
    log.debug("... log4jdbc initialized! ...");
  }

  static RdbmsSpecifics defaultRdbmsSpecifics = new RdbmsSpecifics();

  /**
   * Get the RdbmsSpecifics object for a given Connection.
   *
   * @param conn JDBC connection to get RdbmsSpecifics for.
   * @return RdbmsSpecifics for the given connection.
   */
  static RdbmsSpecifics getRdbmsSpecifics(Connection conn)
  {
    String driverName = "";
    try
    {
      DatabaseMetaData dbm = conn.getMetaData();
      driverName = dbm.getDriverName();
    }
    catch (SQLException s)
    {
      // silently fail
    }

    log.debug("driver name is " + driverName);

    RdbmsSpecifics r = (RdbmsSpecifics) rdbmsSpecifics.get(driverName);

    if (r == null)
    {
      return defaultRdbmsSpecifics;
    }
    else
    {
      return r;
    }
  }

  /**
   * Default constructor.
   */
  public DriverSpy()
  {
  }

  /**
   * Get the major version of the driver.  This call will be delegated to the
   * underlying driver that is being spied upon (if there is no underlying
   * driver found, then 1 will be returned.)
   *
   * @return the major version of the JDBC driver.
   */
  public int getMajorVersion()
  {
    if (lastUnderlyingDriverRequested == null)
    {
      return 1;
    }
    else
    {
      return lastUnderlyingDriverRequested.getMajorVersion();
    }
  }

  /**
   * Get the minor version of the driver.  This call will be delegated to the
   * underlying driver that is being spied upon (if there is no underlying
   * driver found, then 0 will be returned.)
   *
   * @return the minor version of the JDBC driver.
   */
  public int getMinorVersion()
  {
    if (lastUnderlyingDriverRequested == null)
    {
      return 0;
    }
    else
    {
      return lastUnderlyingDriverRequested.getMinorVersion();
    }
  }

  /**
   * Report whether the underlying driver is JDBC compliant.  If there is no
   * underlying driver, false will be returned, because the driver cannot
   * actually do any work without an underlying driver.
   *
   * @return <code>true</code> if the underlying driver is JDBC Compliant;
   *         <code>false</code> otherwise.
   */
  public boolean jdbcCompliant()
  {
    return lastUnderlyingDriverRequested != null &&
      lastUnderlyingDriverRequested.jdbcCompliant();
  }

  /**
   * Returns true if this is a <code>jdbc:log4</code> URL and if the URL is for
   * an underlying driver that this DriverSpy can spy on.
   *
   * @param url JDBC URL.
   *
   * @return true if this Driver can handle the URL.
   *
   * @throws SQLException if a database access error occurs
   */
  public boolean acceptsURL(String url) throws SQLException
  {
    Driver d = getUnderlyingDriver(url);
    if (d != null)
    {
      lastUnderlyingDriverRequested = d;
      return true;
    }
    else
    {
      return false;
    }
  }

  /**
   * Given a <code>jdbc:log4</code> type URL, find the underlying real driver
   * that accepts the URL.
   *
   * @param url JDBC connection URL.
   *
   * @return Underlying driver for the given URL. Null is returned if the URL is
   *         not a <code>jdbc:log4</code> type URL or there is no underlying
   *         driver that accepts the URL.
   *
   * @throws SQLException if a database access error occurs.
   */
  private Driver getUnderlyingDriver(String url) throws SQLException
  {
    if (url.startsWith("jdbc:log4"))
    {
      url = url.substring(9);

      Enumeration e = DriverManager.getDrivers();

      Driver d;
      while (e.hasMoreElements())
      {
        d = (Driver) e.nextElement();

        if (d.acceptsURL(url))
        {
          return d;
        }
      }
    }
    return null;
  }

  /**
   * Get a Connection to the database from the underlying driver that this
   * DriverSpy is spying on.  If logging is not enabled, an actual Connection to
   * the database returned.  If logging is enabled, a ConnectionSpy object which
   * wraps the real Connection is returned.
   *
   * @param url  JDBC connection URL
   * .
   * @param info a list of arbitrary string tag/value pairs as
   *             connection arguments. Normally at least a "user" and
   *             "password" property should be included.
   *
   * @return     a <code>Connection</code> object that represents a
   *             connection to the URL.
   *
   * @throws SQLException if a database access error occurs
   */
  public Connection connect(String url, Properties info) throws SQLException
  {
    Driver d = getUnderlyingDriver(url);
    if (d == null)
    {
      return null;
    }

    // get actual URL that the real driver expects
    // (strip off "jdbc:log4" from url)
    url = url.substring(9);

    lastUnderlyingDriverRequested = d;
    Connection c = d.connect(url, info);

    if (c == null)
    {
      throw new SQLException("invalid or unknown driver url: " + url);
    }
    if (log.isJdbcLoggingEnabled())
    {
      ConnectionSpy cspy = new ConnectionSpy(c);
      RdbmsSpecifics r = null;
      String dclass = d.getClass().getName();
      if (dclass != null && dclass.length() > 0)
      {
        r = (RdbmsSpecifics) rdbmsSpecifics.get(dclass);
      }

      if (r == null)
      {
        r = defaultRdbmsSpecifics;
      }
      cspy.setRdbmsSpecifics(r);
      return cspy;
    }
    else
    {
      return c;
    }
  }

  /**
   * Gets information about the possible properties for the underlying driver.
   *
   * @param url  the URL of the database to which to connect
   *
   * @param info a proposed list of tag/value pairs that will be sent on
   *             connect open
   * @return     an array of <code>DriverPropertyInfo</code> objects describing
   *             possible properties.  This array may be an empty array if no
   *             properties are required.
   *
   * @throws SQLException if a database access error occurs
   */
  public DriverPropertyInfo[] getPropertyInfo(String url, Properties info)
    throws SQLException
  {
    Driver d = getUnderlyingDriver(url);
    if (d == null)
    {
      return new DriverPropertyInfo[0];
    }

    lastUnderlyingDriverRequested = d;
    return d.getPropertyInfo(url, info);
  }
}
